from __future__ import annotations

import asyncio
import re
from dataclasses import dataclass
from functools import cached_property
from typing import Any, Iterable

import httpx
from lxml import etree
from parsel import Selector

from app.models.responses import Candidate as CandidateResponse, LocationResult as LocateResponse


@dataclass
class CandidateNode:
    xpath: str
    css: str | None
    strategy: str


class LocatorService:
    def __init__(self) -> None:
        self._client = httpx.AsyncClient(timeout=10.0)

    async def process_request(self, request: Any) -> LocateResponse:
        html_text = await self._fetch_html(str(request.url))
        selector = Selector(html_text)
        original_elements = selector.xpath(request.xpath)

        if not original_elements:
            raise ValueError("Provided XPath did not match any element")
        if len(original_elements) > 1:
            raise ValueError("Provided XPath matched multiple elements")

        original_element = original_elements[0]
        original_node_html = original_element.get()
        original_node_attributes = dict(original_element.root.attrib)

        generator = CandidateGenerator(selector, original_element, request.css)
        candidate_nodes = generator.generate_candidates()
        verification_results = await asyncio.gather(
            *[
                self._verify_candidate(selector, candidate_node)
                for candidate_node in candidate_nodes
            ]
        )

        verified_candidates = [
            CandidateResponse(
                xpath=result.xpath,
                css=result.css,
                strategy=result.strategy,
                matches=result.matches,
                verification_status=self._get_status(result.matches),
            )
            for result in verification_results
        ]

        return LocateResponse(
            original_node_html=original_node_html,
            original_node_attributes=original_node_attributes,
            candidates=verified_candidates,
        )

    async def _fetch_html(self, url: str) -> str:
        response = await self._client.get(url)
        response.raise_for_status()
        return response.text

    async def _verify_candidate(
        self,
        selector: Selector,
        candidate: CandidateNode,
    ) -> CandidateResponse:
        xpath_matches = selector.xpath(candidate.xpath)
        css_matches = selector.css(candidate.css) if candidate.css else []
        match_count = self._calculate_match_count(xpath_matches, css_matches)

        return CandidateResponse(
            xpath=candidate.xpath,
            css=candidate.css,
            strategy=candidate.strategy,
            matches=match_count,
            verification_status="pending",
        )

    def _calculate_match_count(
        self,
        xpath_matches: Iterable[Selector],
        css_matches: Iterable[Selector],
    ) -> int:
        xpath_elements = {match.root for match in xpath_matches}
        if not css_matches:
            return len(xpath_elements)
        css_elements = {match.root for match in css_matches}
        intersect = xpath_elements & css_elements
        if intersect:
            return len(intersect)
        return min(len(xpath_elements), len(css_elements))

    def _get_status(self, matches: int) -> str:
        if matches == 1:
            return "pass"
        if matches == 0:
            return "fail"
        return "warn"


class CandidateGenerator:
    def __init__(
        self,
        selector: Selector,
        target: Selector,
        provided_css: str | None,
        max_candidates: int = 5,
    ) -> None:
        self.selector = selector
        self.target = target
        self.provided_css = provided_css
        self.max_candidates = max_candidates

    def generate_candidates(self) -> list[CandidateNode]:
        candidates: list[CandidateNode] = []

        if self.provided_css:
            css_xpath = self._css_to_xpath(self.provided_css)
            if css_xpath:
                candidates.append(
                    CandidateNode(
                        xpath=css_xpath,
                        css=self.provided_css,
                        strategy="Provided CSS",
                    )
                )

        builders = [
            self._build_attribute_based_candidate,
            self._build_text_based_candidate,
            self._build_hierarchical_candidate,
            self._build_sibling_context_candidate,
            self._build_indexed_relative_candidate,
        ]

        for builder in builders:
            candidate = builder()
            if candidate:
                candidates.append(candidate)
            if len(candidates) >= self.max_candidates:
                break

        return candidates[: self.max_candidates]

    @cached_property
    def stable_attributes(self) -> dict[str, str]:
        attributes = dict(self.target.root.attrib)
        filtered: dict[str, str] = {}

        for key, value in attributes.items():
            if not value:
                continue
            if key == "id" and self._is_valid_id(value):
                filtered[key] = value
            if key.startswith("data-"):
                filtered[key] = value
            if key == "class":
                stable_classes = self._extract_stable_classes(value)
                if stable_classes:
                    filtered[key] = " ".join(stable_classes)

        return filtered

    def _build_attribute_based_candidate(self) -> CandidateNode | None:
        if not self.stable_attributes:
            return None
        xpath = self._compose_attribute_xpath(self.stable_attributes)
        css = self._compose_attribute_css(self.stable_attributes)
        return CandidateNode(xpath=xpath, css=css, strategy="Stable attributes")

    def _build_text_based_candidate(self) -> CandidateNode | None:
        text = (self.target.xpath("string()").get() or "").strip()
        if not text:
            return None

        normalized = re.sub(r"\s+", " ", text)
        if len(normalized) > 60:
            return None

        escaped = normalized.replace("'", "&apos;")
        xpath = f"//{self.target.root.tag}[contains(normalize-space(.), '{escaped}')]"
        return CandidateNode(xpath=xpath, css=None, strategy="Text content")

    def _build_hierarchical_candidate(self) -> CandidateNode | None:
        path = self._construct_hierarchical_path(depth=4)
        if not path:
            return None
        xpath = "//" + "/".join(path)
        return CandidateNode(xpath=xpath, css=None, strategy="Hierarchical")

    def _build_sibling_context_candidate(self) -> CandidateNode | None:
        parent = self.target.root.getparent()
        if parent is None:
            return None

        siblings = [el for el in parent if isinstance(el.tag, str) and el.tag == self.target.root.tag]
        if len(siblings) <= 1:
            return None

        position = siblings.index(self.target.root) + 1
        parent_xpath = self._get_element_xpath(parent)
        if not parent_xpath:
            return None

        xpath = f"{parent_xpath}/{self.target.root.tag}[{position}]"
        css_selector = self._compose_parent_informed_css(parent, position)

        return CandidateNode(xpath=xpath, css=css_selector, strategy="Sibling structure")

    def _build_indexed_relative_candidate(self) -> CandidateNode | None:
        ancestors = list(self.target.root.iterancestors())
        if not ancestors:
            return None

        chain: list[str] = []
        for ancestor in ancestors[:3]:
            parent = ancestor.getparent()
            siblings = [
                el
                for el in parent or []
                if isinstance(el.tag, str) and el.tag == ancestor.tag
            ]
            index = siblings.index(ancestor) + 1 if siblings else 1
            chain.append(f"{ancestor.tag}[{index}]")

        chain.reverse()
        chain.append(self._get_tag_with_index(self.target.root))
        xpath = "//" + "/".join(chain)
        return CandidateNode(xpath=xpath, css=None, strategy="Indexed path")

    def _compose_attribute_xpath(self, attributes: dict[str, str]) -> str:
        tag = self.target.root.tag
        conditions = [f"@{key}='{value}'" for key, value in attributes.items()]
        return f"//{tag}[{' and '.join(conditions)}]"

    def _compose_attribute_css(self, attributes: dict[str, str]) -> str:
        tag = self.target.root.tag
        selectors = [tag]
        for key, value in attributes.items():
            if key == "id":
                selectors.append(f"#{value}")
            elif key == "class":
                selectors.extend(f".{cls}" for cls in value.split())
            else:
                selectors.append(f"[{key}='{value}']")
        return "".join(selectors)

    def _compose_parent_informed_css(self, parent: etree.Element, position: int) -> str | None:
        parent_id = parent.attrib.get("id")
        parent_classes = parent.attrib.get("class")
        if not parent_id and not parent_classes:
            return None

        parent_selector = parent.tag
        if parent_id:
            parent_selector += f"#{parent_id}"
        if parent_classes:
            parent_selector += "." + ".".join(parent_classes.split())

        child_selector = f"> {self.target.root.tag}:nth-of-type({position})"
        return parent_selector + " " + child_selector

    def _construct_hierarchical_path(self, depth: int) -> list[str]:
        current = self.target.root
        segments: list[str] = []

        while current is not None and depth > 0:
            segment = self._get_tag_with_index(current)
            segments.append(segment)
            current = current.getparent()
            depth -= 1

        segments.reverse()
        return segments

    def _get_tag_with_index(self, element: etree.Element) -> str:
        parent = element.getparent()
        if parent is None:
            return element.tag

        siblings = [child for child in parent if isinstance(child.tag, str) and child.tag == element.tag]
        if len(siblings) == 1:
            return element.tag

        position = siblings.index(element) + 1
        return f"{element.tag}[{position}]"

    def _css_to_xpath(self, css: str) -> str | None:
        try:
            converted = Selector(text="", type="html").css(css).xpath
            if hasattr(converted, "expr"):
                return str(converted.expr)
        except Exception:
            return None
        return None

    def _extract_stable_classes(self, class_value: str) -> list[str]:
        classes = [token for token in class_value.split() if token]
        meaningful = [token for token in classes if len(token) >= 4 and not token.isdigit()]
        return meaningful[:3]

    def _is_valid_id(self, value: str) -> bool:
        if not value:
            return False
        if value.isdigit():
            return False
        if len(value) <= 2:
            return False
        return bool(re.match(r"^[A-Za-z][\w\-:.]{2,}$", value))

    def _get_element_xpath(self, element: etree.Element) -> str | None:
        try:
            return etree.ElementTree(element).getpath(element)
        except ValueError:
            return None
